03 - 路由与容错:选哪个后端,怎么算出来的
网关最核心的一次决策发生在毫秒级:同一个模型名下挂着 N 个后端,这次请求发给谁。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| deployment(后端) | 同一个模型名底下挂的一个具体可调地址。比如「gpt-4o」这个名字下可能挂着三把不同的 key、两个不同区域的端点,每一个都是一个 deployment |
| TPM / RPM | Tokens Per Minute / Requests Per Minute,provider 给每把 key 定的两种限额。路由必须同时看这两个数,否则会把请求发给一个马上要返回 429 的后端 |
| 429 | HTTP 状态码 Too Many Requests,即被限流了 |
| P99 | 把所有请求的耗时排序后第 99 百分位的值。它比平均值更能反映「最差的那批用户体验如何」 |
| buffer(缓冲档) | 最低延迟策略里的一个阈值:和最快值相差在这个范围内的后端算作同一档,档内随机选。没有它,所有请求会一起涌向当前最快的那一个 |
| 冷却(cooldown) | 后端连续失败后被暂时移出可用列表的那段时间,到点自动放回 |
| CAS | Compare-And-Swap,比较并交换。一种无锁的并发写入手段:只有当前值和我读到的一样时才写进去,否则重试 |
LiteLLM 把这个决策抽象成了 router_strategy/ 目录下的一组策略类,每个策略回答同一个问题的不同版本。
litellm/router_strategy/
├── lowest_latency.py 542 行 ← 最低延迟
├── lowest_tpm_rpm_v2.py 624 行 ← 最少已用配额
├── lowest_cost.py 305 行 ← 最低单价
├── budget_limiter.py 843 行 ← 预算过滤
├── tag_based_routing.py 668 行 ← 标签匹配
└── complexity_router/ 119 KB ← 按请求复杂度选模型
前置:01 - 网关是什么 里的三个词 —— deployment(一个具体可调用的模型端点)、TPM / RPM(provider 给的每分钟 token / 请求硬上限)、fallback(失败了换一个)。这三个不清楚的话先回去看一眼,本篇全程用它们。
本篇回答:同一个模型名下挂着 5 个 deployment,网关凭什么选中其中一个?这个决策的真实代码长什么样、藏着哪些坑。
读法建议:第一节(最低延迟)是全篇最重要的,四个步骤每一步都对应一个真实的线上故障。看懂它,后面几种策略都是同一套骨架换个排序依据。
一、最低延迟策略
最低延迟策略的选择过程分四步,每一步都对应一个真实的线上故障:
第三步与第四步是这套实现里最容易被简化掉、也最不该简化的两步 —— 原因见 1.3 与 1.4 节。
lowest_latency.py 的核心是 _get_available_deployments()。整个流程分成四步,每一步都藏着一个工程决策。
四步里最容易被忽略的是步骤 4 的随机——下面会看到它防的是一个真实会炸的线上问题。
1.1 延迟数据的存储位置与时效
class RoutingArgs(LiteLLMPydanticObjectBase):
ttl: float = 1 * 60 * 60 # 1 hour
lowest_latency_buffer: float = 0
max_latency_list_size: int = 10
每个后端只保留最近 10 次延迟,TTL 一小时。 不是滑动窗口平均,不是 EWMA,就是一个定长列表。写入时:
if len(request_count_dict[id].get("latency", [])) < self.routing_args.max_latency_list_size:
request_count_dict[id].setdefault("latency", []).append(final_value)
else:
request_count_dict[id]["latency"] = request_count_dict[id]["latency"][:-1] + [final_value]
这个实现有个容易忽略的性质:列表满了之后,替换的是最后一个元素,而不是最老的元素。 也就是说前 9 个样本一旦写 进去就再也不会被挤出,只有第 10 个位置在滚动。延迟统计因此会带上很重的历史惯性 —— 这在后端性能长期稳定时无所谓,但在后端刚从故障中恢复时,会让它长时间"背着旧账"。
1.2 流式请求用 TTFT,非流式用总延迟
use_ttft = (
request_kwargs is not None
and request_kwargs.get("stream", None) is not None
and request_kwargs["stream"] is True
and len(item_ttft_latency) > 0
)
if use_ttft:
for _call_latency in item_ttft_latency:
if isinstance(_call_latency, float):
total += _call_latency
item_latency = total / len(item_ttft_latency)
else:
# 用总延迟
这一步是整个策略里最正确的设计。 流式场景下用户感知的是首字延迟(TTFT),非流式场景下感知的是总耗时,两者根本不是同一个指标。而且 TTFT 在记录时还做了归一化:
time_to_first_token = safe_divide_seconds(ttft_seconds, completion_tokens)
除以了输出 token 数 —— 严格说这算出来的是"每 token 的平均首字延迟",语义上有点怪,但它让长短不一的请求可以横向比较。
1.3 先按配额硬过滤,再排序
if (
item_tpm + input_tokens > _deployment_tpm or item_rpm + 1 > _deployment_rpm
):
continue
else:
potential_deployments.append((_deployment, item_latency))
配额是按分钟粒度记的,key 长这样:
current_date = datetime.now().strftime("%Y-%m-%d")
current_hour = datetime.now().strftime("%H")
current_minute = datetime.now().strftime("%M")
precise_minute = f"{current_date}-{current_hour}-{current_minute}"
注意这是自然分钟对齐,不是滑动窗口。 意味着每分钟的第 0 秒配额会瞬间清零,突发流量可以在分钟交界处打出两倍于限额的量。这是所有用"当前分钟做 key"的限流实现的通病,第 04 篇会看到 Envoy 用完全不同的方式处理它。
1.4 在最快的一档内随机选取
sorted_deployments = sorted(potential_deployments, key=lambda x: x[1])
lowest_latency = sorted_deployments[0][1]
buffer = self.routing_args.lowest_latency_buffer * lowest_latency
valid_deployments = [x for x in sorted_deployments if x[1] <= lowest_latency + buffer]
random_valid_deployment = random.choice(valid_deployments)
这是整个策略里最关键的一行工程智慧。 如果严格选最快的那个,所有请求会瞬间涌向同一个后端,把它打慢,然后集体切到下一个 —— 典型的羊群效应。加一个 buffer 让"差不多快"的后端都进入候选,再随机选,把抖动抹平。
而且在这之前还有一次洗牌:
# randomly sample from all_deployments, incase all deployments have latency=0.0
_all_deployments = random.sample(list(_items), len(_items))
冷启动时所有后端延迟都是 0,如果不洗牌,sorted 的稳定性会让所有请求全打给列表里的第一个。这行注释写得很朴实,但它防的是一个真实会炸的线上问题。
二、最低成本策略与它的默认值风险
lowest_cost.py 的逻辑简单得多 —— 算单价,排序,取第一个:
potential_deployments = sorted(potential_deployments, key=lambda x: x[1])
selected_deployment = potential_deployments[0][0]
注意它没有 buffer + 随机那一套,因为成本是确定值不会抖动。
真正值得注意的是取不到价格时的兜底:
if item_input_cost is None:
item_input_cost = item_litellm_model_cost_map.get("input_cost_per_token", 5.0)
if item_output_cost is None:
item_output_cost = item_litellm_model_cost_map.get("output_cost_per_token", 5.0)
# if litellm["model"] is not in model_cost map -> use item_cost = $10
item_cost = item_input_cost + item_output_cost
价格表里查不到的模型,单价按 5.0 + 5.0 算。 这是一个天文数字级别的惩罚性默认值(真实模型的 input_cost_per_token 通常在 1e-6 量级),效果等于"未知模型永远不会被选中"。
这个设计是对的 —— 成本路由下选一个不知道价格的模型是不可接受的风险。但它有个副作用:自建的、私有的、刚上线还没进 model_cost 表的模型,在成本路由下会被静默地完全排除。 排查这类"我的模型怎么永远没流量"的问题时,这是第一个该看的地方。
三、标签路由
tag_based_routing.py 的文件头把语义写得很清楚:
- If tags in request is a subset of tags in deployment, return deployment
- if deployments are set with default tags, return all default deployment
- If no default_deployments are set, return all deployments
匹配函数支持两种模式:
def is_valid_deployment_tag(
deployment_tags: Sequence[str], request_tags: Sequence[str], match_any: bool = True
) -> bool:
if not request_tags:
return False
dep_set = set(deployment_tags)
req_set = set(request_tags)
match_any=True 是默认值(交集非空即匹配),False 时要求请求标签是后端标签的子集。还支持正则形式的标签(_is_valid_deployment_tag_regex)。
标签路由是从"路由"通向"多租户"的桥。 一旦你能给请求打上 team:research、env:prod 这样的标签并据此选后端,配额、计费、隔离就都有了挂载点 —— 这条线在第 04 篇继续。
四、预算过滤
budget_limiter.py 和上面几个策略不是一个层次的东西。它不参与"选谁",而是在选之前先把没钱的后端踢出去:
class RouterBudgetLimiting(CustomLogger):
async def async_filter_deployments(...)
def _filter_out_deployments_above_budget(...)
支持两个粒度,provider 级和 deployment 级:
current_spend = spend_map.get(f"provider_spend:{provider}:{config.budget_duration}", 0.0)
if config.max_budget and current_spend >= config.max_budget:
debug_msg = f"Exceeded budget for provider {provider}: {current_spend} >= {config.max_budget}"
current_spend = spend_map.get(f"deployment_spend:{model_id}:{config.budget_duration}", 0.0)
Key 的构造方式(provider_spend:{provider}:{duration})说明花费是按 provider + 周期聚合的,周期本身是 key 的一部分 —— 换周期等于换一套计数器,天然避免了跨周期污染。